/*
 * This file is part of Cadmium.
 * Copyright (C) 2007-2010 Xavier Clerc.
 *
 * Cadmium is free software; you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation; either version 3 of the License, or
 * (at your option) any later version.
 *
 * Cadmium is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package fr.x9c.cadmium.primitives.graph;

import java.awt.BasicStroke;
import java.awt.BorderLayout;
import java.awt.Canvas;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Frame;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.HeadlessException;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseMotionAdapter;
import java.awt.image.BufferedImage;
import java.util.Formatter;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;

import javax.swing.JFrame;

import fr.x9c.cadmium.kernel.AbstractParameters;
import fr.x9c.cadmium.kernel.CodeRunner;
import fr.x9c.cadmium.kernel.Context;
import fr.x9c.cadmium.kernel.Fail;
import fr.x9c.cadmium.kernel.FalseExit;
import fr.x9c.cadmium.kernel.Value;
import fr.x9c.cadmium.util.Misc;

/**
 * This class represents the context associated with 'Graphics' module.
 *
 * @author <a href="mailto:cadmium@x9c.fr">Xavier Clerc</a>
 * @version 1.0
 * @since 1.0
 */
public final class GraphSlot {

    /** Identifier for <tt>GraphSlot</tt>. */
    public static final Object SLOT = new Object();

    /**
     * Identifier for frame title
     * (should be remembered across open/close cycles).
     */
    static final Object SLOT_TITLE = new Object();

    /** Default frame title. */
    static final String DEFAULT_TITLE = "Caml graphics";

    /** Default frame width. */
    private static final int DEFAULT_WIDTH = 640;

    /** Default frame height. */
    private static final int DEFAULT_HEIGHT = 480;

    /** Default font name. */
    private static final String DEFAULT_FONT_NAME = "Dialog";

    /** Default font style. */
    private static final int DEFAULT_FONT_STYLE = Font.PLAIN;

    /** Default font size. */
    private static final int DEFAULT_FONT_SIZE = 12;

    /** Maximum number of events to keep in queue. */
    private static final int EVENT_QUEUE_SIZE = 256;

    /** Canvas covering all frame space. */
    private final GraphCanvas canvas;

    /** Displayed frame. */
    private final Frame frame;

    /** Whether primitives should write on frame. */
    private boolean displayMode;

    /** Whether primitives should write on backing store. */
    private boolean rememberMode;

    /** Font used for text drawing. */
    private Font font;

    /** Background color. */
    private final Color background;

    /** Foreground color. */
    private Color color;

    /** Buffer representing frame content. */
    private BufferedImage buffer;

    /** Buffer representing backing store (backbuffer) content. */
    private BufferedImage backingStore;

    /** X-component of current position. */
    private int x;

    /** Y-component of current position. */
    private int y;

    /** Line width for drawing. */
    private int lineWidth;

    /** Whether a mouse button is currently pressed. */
    private boolean mouseButton;

    /** Event queue, lower index means older event. */
    private final List<Event> eventQueue;

    /**
     * Constructs a graph slot, with associated frame and needed elements.
     * @param params interpreter parameters - should not be <tt>null</tt>
     * @param id frame title - should not be <tt>null</tt>
     * @param geom frame geometry, should be <i>width<tt>x</tt>height</i>
     *             - should not be <tt>null</tt>
     * @throws HeadlessException if fame creation is impossible
     */
    public GraphSlot(final AbstractParameters params,
                     final String id,
                     final String geom)
        throws HeadlessException {
        assert params != null : "null params";
        assert id != null : "null id";
        assert geom != null : "null geom";

        // default values
        this.displayMode = true;
        this.rememberMode = true;
        this.font = new Font(GraphSlot.DEFAULT_FONT_NAME,
                             GraphSlot.DEFAULT_FONT_STYLE,
                             GraphSlot.DEFAULT_FONT_SIZE);
        this.background = Color.WHITE;
        this.color = Color.BLACK;
        this.x = 0;
        this.y = 0;
        this.lineWidth = 1;
        this.mouseButton = false;
        this.eventQueue = new LinkedList<Event>();

        // geometry parsing
        final int sx;
        final int sy;
        if (geom.matches("[0-9]+x[0-9]+")) {
            final String[] xy = geom.split("x");
            sx = Integer.parseInt(xy[0]);
            sy = Integer.parseInt(xy[1]);
        } else {
            sx = GraphSlot.DEFAULT_WIDTH;
            sy = GraphSlot.DEFAULT_HEIGHT;
        } // end if/else

        // buffer creation
        this.buffer = new BufferedImage(sx, sy, BufferedImage.TYPE_INT_ARGB);
        final Graphics2D g = this.buffer.createGraphics();
        g.setColor(Color.WHITE);
        g.fillRect(0, 0, sx, sy);
        g.dispose();

        // backing store creation
        this.backingStore = new BufferedImage(sx, sy, BufferedImage.TYPE_INT_ARGB);
        final Graphics2D g2 = this.backingStore.createGraphics();
        g2.setColor(Color.WHITE);
        g2.fillRect(0, 0, sx, sy);
        g2.dispose();

        // canvas creation & event listener
        this.canvas = new GraphCanvas(sx, sy);
        this.canvas.addMouseListener(new MouseAdapter() {
                public void mousePressed(final MouseEvent e) {
                    synchronized (GraphSlot.this) {
                        e.consume();
                        GraphSlot.this.mouseButton = true;
                        addEvent(new Event(Events.EVENT_BUTTON_DOWN,
                                           e.getX(),
                                           e.getY(),
                                           true,
                                           null));
                    } // end synchronized
                } // end method 'mousePressed(MouseEvent)'
                public void mouseReleased(final MouseEvent e) {
                    synchronized (GraphSlot.this) {
                        e.consume();
                        GraphSlot.this.mouseButton = false;
                        addEvent(new Event(Events.EVENT_BUTTON_UP,
                                           e.getX(),
                                           e.getY(),
                                           false,
                                           null));
                    } // end synchronized
                } // end method 'mouseReleased(MouseEvent)'
            });
        this.canvas.addMouseMotionListener(new MouseMotionAdapter() {
                public void mouseMoved(final MouseEvent e) {
                    synchronized (GraphSlot.this) {
                        e.consume();
                        addEvent(new Event(Events.EVENT_MOUSE_MOTION,
                                           e.getX(),
                                           e.getY(),
                                           e.getButton() != MouseEvent.NOBUTTON,
                                           null));
                    } // end synchronized
                } // end method 'mouseMoved(MouseEvent)'
            });
        this.canvas.addKeyListener(new KeyAdapter() {
                public void keyPressed(final KeyEvent e) {
                    synchronized (GraphSlot.this) {
                        e.consume();
                        addEvent(new Event(Events.EVENT_KEY_PRESSED,
                                           0,
                                           0,
                                           false,
                                           Misc.signedToUnsignedByte(Misc.convertCharToByte(e.getKeyChar()))));
                    } // end synchronized
                } // end method 'keyPressed(KeyEvent)'
            });

        if (params.isBareCanvas()) {
            this.frame = null;
        } else {
            if (params.isFrameAWT()) {
                this.frame = new Frame(id);
            } else {
                final JFrame jf = new JFrame(id);
                jf.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
                this.frame = jf;
            } // end if/else
            this.frame.setLayout(new BorderLayout());
            this.frame.add(this.canvas, BorderLayout.CENTER);
            this.frame.pack();
            this.frame.setVisible(true);
        } // end if/else
    } // end constructor(AbstractParameters, String, String)

    /**
     * Raises a <i>Graphics.Graphic_failure</i> exception if the
     * corresponding callback has been registered,
     * an <i>Invalid_argument</i> exception otherwise.
     * @param ctxt context - should not be <tt>null</tt>
     * @param fmt format string - should not be <tt>null</tt>
     * @param arg argument to format string
     * @throws Fail.Exception always
     */
    static void fail(final CodeRunner ctxt,
                     final String fmt,
                     final String arg) throws Fail.Exception {
        assert ctxt != null : "null ctxt";
        assert fmt != null : "null fmt";
        final Value exn =
            ctxt.getContext().getCallback("Graphics.Graphic_failure");
        if (exn == null) {
            Fail.invalidArgument("Exception Graphics.graphic_failure not initialized, must link graphics.cma");
        } else {
            final StringBuilder sb = new StringBuilder();
            final Formatter f = new Formatter(sb);
            f.format(fmt, arg);
            Fail.raiseWithString(exn, sb.toString());
        } // end if
    } // end method 'fail(CodeRunner, String, String)'

    /**
     * Checks whether the graph library has been initialized in a given
     * context.
     * @param ctxt context - should not be <tt>null</tt>
     * @return graph slot associated with passed context
     * @throws Fail.Exception if the graph library has not been initialized
     *                        with the passed context
     */
    static GraphSlot checkGraph(final CodeRunner ctxt)
        throws Fail.Exception {
        assert ctxt != null : "null ctxt";
        final GraphSlot res =
            (GraphSlot) ctxt.getContext().getSlot(GraphSlot.SLOT);
        if (res != null) {
            return res;
        } else {
            fail(ctxt, "Graphic screen not opened", "");
            return null; // never reached
        } // end if/else
    } // end method 'checkGraph(CodeRunner)'

    /**
     * Returns the frame containing graphics. <br/>
     * Returned frame can be a Swing or an AWT one. <br/>
     * Can be <tt>null</tt> if no frame has been created.
     * @return the frame containing graphics
     */
    public Frame getFrame() {
        return this.frame;
    } // end method 'getFrame()'

    /**
     * Returns the canvas containing graphics.
     * @return the canvas containing graphics
     */
    public Canvas getCanvas() {
        return this.canvas;
    } // end method 'getCanvas()'

    /**
     * Changes the display mode
     * (whether graphics primitive should modify frame content).
     * @param b whether display mode should be activated
     */
    void setDisplayMode(final boolean b) {
        this.displayMode = b;
    } // end method 'setDisplayMode(boolean)'

    /**
     * Changes the remember mode
     * (whether graphics primitive should modify backing store).
     * @param b whether remember mode should be activated
     */
    void setRememberMode(final boolean b) {
        this.rememberMode = b;
    } // end method 'setRememberMode(boolean)'

    /**
     * Returns the current font for text drawing.
     * @return the current font for text drawing
     */
    public Font getFont() {
        return this.font;
    } // end method 'getFont()'

    /**
     * Changes the font for text drawing.
     * @param f new font - should not be <tt>null</tt>
     */
    public void setFont(final Font f) {
        assert f != null : "null f";
        this.font = f;
    } // end method 'setFont(Font)'

    /**
     * Returns the current background color.
     * @return the current background color
     */
    public Color getBackground() {
        return this.background;
    } // end method 'getBackground()'

    /**
     * Returns the current foreground color.
     * @return the current foreground color
     */
    public Color getColor() {
        return this.color;
    } // end method 'getColor()'

    /**
     * Changes the foreground color.
     * @param c new foreground color - should not be <tt>null</tt>
     */
    public void setColor(final Color c) {
        assert c != null : "null c";
        this.color = c;
    } // end method 'setColor(Color)'

    /**
     * Returns the underlying buffer, that is frame content.
     * @return the underlying buffer
     */
    public BufferedImage getBuffer() {
        return this.buffer;
    } // end method 'getBuffer()'

    /**
     * Returns the backing store, that is backbuffer content.
     * @return the backing store
     */
    public BufferedImage getBackingStore() {
        return this.backingStore;
    } // end method 'getBackingStore()'

    /**
     * Creates a graphics context for drawing operations on frame content.
     * @return graphics context, or <tt>null</tt> if display mode is off
     */
    Graphics2D createWindowGraphics() {
        if (this.displayMode) {
            final Graphics2D g = this.buffer.createGraphics();
            g.setFont(this.font);
            g.setColor(this.color);
            g.setStroke(new BasicStroke(this.lineWidth));
            return g;
        } else {
            return null;
        } // end if/else
    } // end method 'createWindowGraphics()'

    /**
     * Creates a graphics context for drawing operations on backing store.
     * @return graphics context, or <tt>null</tt> if display mode is off
     */
    Graphics2D createBackingStoreGraphics() {
        if (this.rememberMode) {
            final Graphics2D g = this.backingStore.createGraphics();
            g.setFont(this.font);
            g.setColor(this.color);
            g.setStroke(new BasicStroke(this.lineWidth));
            return g;
        } else {
            return null;
        } // end if/else
    } // end method 'createBackingStoreGraphics()'

    /**
     * Returns x-component of current position.
     * @return x-component of current position
     */
    public int getX() {
        return this.x;
    }  // end method 'getX()'

    /**
     * Changes the x-component of position.
     * @param x new x-component
     */
    public void setX(final int x) {
        this.x = x;
    } // end method 'setX(int)'

    /**
     * Returns y-component of current position.
     * @return y-component of current position
     */
    public int getY() {
        return this.y;
    } // end method 'getY()'

    /**
     * Changes the y-component of position.
     * @param y new y-component
     */
    public void setY(final int y) {
        this.y = y;
    } // end method 'setY(int)'

    /**
     * Returns the width of the graphics space.
     * @return the width of the graphics space
     */
    public int getWidth() {
        return this.canvas.width;
    } // end method 'getWidth()'

    /**
     * Returns the height of the graphics space.
     * @return the height of the graphics space
     */
    public int getHeight() {
        return this.canvas.height;
    } // end method 'getHeight()'

    /**
     * Convert y-component of a coordinate from Caml-space to Java-space.
     * @param y y-component of a coordinate in Caml-space
     * @return corresponding y-component in Java-space
     */
    int convertY(final int y) {
        return this.buffer.getHeight() - 1 - y;
    } // end method 'convertY(int)'

    /**
     * Returns the current line width for drawings.
     * @return the current line width for drawings
     */
    public int getLineWidth() {
        return this.lineWidth;
    } // end method 'getLineWidth() {'

    /**
     * Changes the line width for drawings.
     * @param lw new line width
     */
    public void setLineWidth(final int lw) {
        this.lineWidth = Math.max(1, lw);
    } // end method 'setLineWidth(final int lw) {'

    /**
     * Tests whether a mouse button is currently pressed.
     * @return <tt>true</tt> if a mouse button is currently pressed,
     *         <tt>false</tt> otherwise
     */
    synchronized boolean isMouseButton() {
        return this.mouseButton;
    } // end method 'isMouseButton()'

    /**
     * Adds an event to the event queue and resizes it if needed
     * (At most {@link #EVENT_QUEUE_SIZE} events are remembered).
     * @param e event to add to event queue
     */
    private void addEvent(final Event e) {
        synchronized (this.eventQueue) {
            this.eventQueue.add(e);
            while (this.eventQueue.size() > GraphSlot.EVENT_QUEUE_SIZE) {
                this.eventQueue.remove(0);
            } // end while
            this.eventQueue.notifyAll();
        } // end synchronized
    } // end method 'addEvent(Event)'

    /**
     * Waits for an event, all events before the one returned are removed from
     * the event queue. If no matching event is present in the event queue,
     * the current thread is suspended until one is enqueued.
     * @param ctxt context - should not be <tt>null</tt>
     * @param pressed whether the thread should wait for a mouse button press
     * @param released whether the thread should wait for a mouse button release
     * @param keyPressed whether the thread should wait for a key press
     * @param motion whether the thread should wait for a mouse motion
     * @return <i>(mouse_x, mouse_y, is_button_pressed, is_key_pressed, key)</i>
     */
    Value waitEvent(final Context ctxt,
                    final boolean pressed,
                    final boolean released,
                    final boolean keyPressed,
                    final boolean motion)
        throws FalseExit, Fail.Exception {
        while (true) {
            synchronized (this.eventQueue) {
                final Iterator<Event> it = this.eventQueue.iterator();
                while (it.hasNext()) {
                    final Event e = it.next();
                    it.remove();
                    final int kind = e.getKind();
                    if (((kind == Events.EVENT_BUTTON_DOWN) && pressed)
                        || ((kind == Events.EVENT_BUTTON_UP) && released)
                        || ((kind == Events.EVENT_KEY_PRESSED) && keyPressed)
                        || ((kind == Events.EVENT_MOUSE_MOTION) && motion)) {
                        return Events.allocateResult(this,
                                                     e.getMouseX(),
                                                     e.getMouseY(),
                                                     e.isButton(),
                                                     e.getKey());
                    } // end if
                } // end while
                try {
                    this.eventQueue.wait();
                } catch (final InterruptedException ie) {
                    final FalseExit fe = FalseExit.createFromContext(ctxt);
                    fe.fillInStackTrace();
                    throw fe;
                } // end try/catch
            } // end synchronized
        } // end while
    } // end method 'waitEvent(boolean, boolean, boolean, boolean)'

    /**
     * Returns the first key event from the event queue. <br/>
     * No event is removed from the queue.
     * @return the first key event from the event queue
     */
    Integer getKey() {
        synchronized (this.eventQueue) {
            final Iterator<Event> it = this.eventQueue.iterator();
            while (it.hasNext()) {
                final Event e = it.next();
                if (e.getKind() == Events.EVENT_KEY_PRESSED) {
                    return e.getKey();
                } // end if
            } // end while
        } // end synchronized
        return null;
    } // end method 'getKey()'

    /**
     * Changes the size of the canvas, hence the size of the frame. <br/>
     * Does nothing if no frame has been created.
     * @param w new canvas width
     * @param h new canvas height
     */
    void changeSize(final int w, final int h) {
        if (this.frame != null) {
            this.canvas.changeSize(w, h);
            this.frame.pack();
        } // end if
    } // end method 'changeSize(int, int)'

    /**
     * Asks for a complete repaint of the frame.
     */
    void repaint() {
        this.canvas.repaint();
        java.awt.Toolkit.getDefaultToolkit().sync();
    } // end method 'repaint()'

    /**
     * This inner-class represents event associated with the graphics frame.
     */
    private static final class Event {

        /** Event kind. */
        private final int kind;

        /** X-component of mouse coordinate. */
        private final int mouseX;

        /** Y-component of mouse coordinate. */
        private final int mouseY;

        /** Whether a button was pressed. */
        private final boolean button;

        /** Keycode associated with event, <tt>null</tt> if none. */
        private final Integer key;

        /**
         * Constructs a new event from kind, mouse data and key code.
         * @param k event kind
         * @param x x-component of mouse coordinate
         * @param y y-component of mouse coordinate
         * @param btn whether a mouse button is pressed
         * @param key key code, <tt>null</tt> if none
         */
        private Event(final int k,
                      final int x,
                      final int y,
                      final boolean btn,
                      final Integer key) {
            this.kind = k;
            this.mouseX = x;
            this.mouseY = y;
            this.button = btn;
            this.key = key;
        } // end constructor(int, int, int, boolean, Integer)

        /**
         * Returns the event kind.
         * @return the event kind
         */
        private int getKind() {
            return this.kind;
        } // end method 'getKind()'

        /**
         * Returns the event x-component of mouse coordinate.
         * @return the event x-component of mouse coordinate
         */
        private int getMouseX() {
            return this.mouseX;
        } // end method 'getMouseX()'

        /**
         * Returns the event y-component of mouse coordinate.
         * @return the event y-component of mouse coordinate
         */
        private int getMouseY() {
            return this.mouseY;
        } // end method 'getMouseY()'

        /**
         * Returns whether a button was pressed, according to this event.
         * @return <tt>true</tt> if a button was pressed,
         *         <tt>false</tt> otherwise
         */
        private boolean isButton() {
            return this.button;
        } // end method 'isButton()'

        /**
         * Returns the key code associated with the event.
         * @return the key code associated with the event,
         *         <tt>null</tt> if none
         */
        private Integer getKey() {
            return this.key;
        } // end method 'getKey()'

    } // end inner-class 'Event'

    /**
     * This inner-class represents the kind of canvas occupying all frame
     * space. <br/>
     * Its only purpose is to render content for graph slot buffer upon
     * paint requests.
     */
    private final class GraphCanvas extends Canvas {

        /** Serialization UID. */
        static final long serialVersionUID = -4643415751268650649L;

        /** Canvas width. */
        private int width;

        /** Canvas height. */
        private int height;

        /**
         * Creates a canvas from width and height.
         * @param w canvas width
         * @param h canvas height
         */
        private GraphCanvas(final int w, final int h) {
            this.width = w;
            this.height = h;
        } // end constructor(int, int)

        /**
         * Changes canvas size.
         * @param w new canvas width
         * @param h new canvas height
         */
        private void changeSize(final int w, final int h) {
            this.width = w;
            this.height = h;
            setSize(w, h);
        } // end method 'changeSize(int, int)'

        /**
         * {@inheritDoc}
         */
        @Override
        public Dimension getSize(final Dimension rv) {
            rv.width = this.width;
            rv.height = this.height;
            return new Dimension(this.width, this.height);
        } // end method 'getSize(Dimension)'

        /**
         * {@inheritDoc}
         */
        @Override
        public Dimension getPreferredSize() {
            return new Dimension(this.width, this.height);
        } // end method 'getPreferredSize()'

        /**
         * {@inheritDoc}
         */
        @Override
        public Dimension getMinimumSize() {
            return new Dimension(this.width, this.height);
        } // end method 'getMinimumSize()'

        /**
         * {@inheritDoc}
         */
        @Override
        public Dimension getMaximumSize() {
            return new Dimension(this.width, this.height);
        } // end method 'getMaximumSize()'

        /**
         * {@inheritDoc}
         */
        @Override
        public synchronized void paint(final Graphics g) {
            final Dimension dim = getSize();
            final int w = dim.width;
            final int h = dim.height;
            if ((GraphSlot.this.buffer.getWidth() != w)
                || (GraphSlot.this.buffer.getHeight() != h)) {
                final int dh = h - GraphSlot.this.buffer.getHeight();
                // new buffer
                final BufferedImage newBuffer =
                    new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
                final Graphics2D g2 = newBuffer.createGraphics();
                g2.setColor(GraphSlot.this.background);
                g2.fillRect(0, 0, w, h);
                g2.drawImage(GraphSlot.this.buffer, 0, dh, null);
                g2.dispose();
                GraphSlot.this.buffer = newBuffer;

                // new backing store
                final BufferedImage newBackingStore =
                    new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
                final Graphics2D g3 = newBackingStore.createGraphics();
                g3.setColor(GraphSlot.this.background);
                g3.fillRect(0, 0, w, h);
                g3.drawImage(GraphSlot.this.backingStore, 0, dh, null);
                g3.dispose();
                GraphSlot.this.backingStore = newBackingStore;
            } // end if
            super.paint(g);
            g.drawImage(GraphSlot.this.buffer, 0, 0, null);
        } // end method 'paint(Graphics)'

    } // end inner-class 'GraphCanvas'

} // end class 'GraphSlot'
